package protocol.channels.control;

import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.InetAddress;
import java.net.SocketException;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.HashMap;
import java.util.Map.Entry;

import exceptions.ArgumentException;
import files.FileHandler;
import general.SocketHelper;
import virtualdatagramsocket.VirtualDatagramSocket;
import protocol.Frame;
import protocol.Receiver;
import protocol.Sender;
import protocol.control.Packet;
import protocol.control.ReceiveInfoPacket;
import protocol.control.ReceiveRequestPacket;
import protocol.control.SendInfoPacket;
import protocol.control.SendRequestPacket;

public class SenderControlChannel extends ControlChannel implements Runnable {
	
	private boolean ThreeWayHandshakeCompleted;
	
	public boolean getThreeWayHandshakeCompleted() {
		return this.ThreeWayHandshakeCompleted;
	}
	
	private void setThreeWayHandshakeCompleted(boolean threeWayHandshakeCompleted) {
		this.ThreeWayHandshakeCompleted = threeWayHandshakeCompleted;
	}
	
	/**
	 * Send requests
	 */
	private HashMap<String, Receiver> sendRequests;
	
	private HashMap<String, Receiver> getSendRequests() {
		return this.sendRequests;
	}
	
	private void setSendRequests(HashMap<String, Receiver> requests) {
		this.sendRequests = requests;
	}
	
	/**
	 * Send request send times
	 */
	private HashMap<String, Long> sendRequestSendTimes;
	
	private HashMap<String, Long> getSendRequestSendTimes() {
		return this.sendRequestSendTimes;
	}

	private void setSendRequestSendTimes(HashMap<String, Long> sendRequestSendTimes) {
		this.sendRequestSendTimes = sendRequestSendTimes;
	}

	/**
	 * Receive requests
	 */
	private ArrayList<String> receiveRequests;
	
	private ArrayList<String> getReceiveRequests() {
		return this.receiveRequests;
	}
	
	private void setReceiveRequests(ArrayList<String> requests) {
		this.receiveRequests = requests;
	}

	/**
	 * Receive request send times
	 */
	private HashMap<String, Long> receiveRequestSendTimes;
	
	private HashMap<String, Long> getReceiveRequestSendTimes() {
		return this.receiveRequestSendTimes;
	}

	private void setReceiveRequestSendTimes(
			HashMap<String, Long> receiveRequestSendTimes) {
		this.receiveRequestSendTimes = receiveRequestSendTimes;
	}

	/**
	 * Constructor
	 */
	public SenderControlChannel() {
		this.setSendRequests(new HashMap<String, Receiver>());
		this.setSendRequestSendTimes(new HashMap<String, Long>());
		this.setReceiveRequests(new ArrayList<String>());
		this.setReceiveRequestSendTimes(new HashMap<String, Long>());
		this.setThreeWayHandshakeCompleted(false);
	}

	/** 
	 * 
	 * @param host
	 * @param port
	 * @throws IOException 
	 */
	public void connect(String host, int port) throws IOException {
		VirtualDatagramSocket socket = new VirtualDatagramSocket();
		try {
			socket.setPacketDelayRate(0);
			socket.setPacketLossRate(0);
		} catch (Exception e) {
			e.printStackTrace();
		}
		socket.connect(InetAddress.getByName(host), port);
		this.setSocket(socket);
	}
		
	public void run() {
		/**
		 * The current states of the 3-way handshakes.
		 */
		int synHandshakeState = 0; // used for connection initialization
		//int finHandshakeState = 0; // used for connection shutdown
		try {
			while (this.getSocket().isConnected()) {
				try {
					this.retransmit();
				} catch (ArgumentException e) {
					System.err.println(String.format("ERROR: %s", e.toString()));
				}
				/**
				 * All other incoming data is dropped 
				 * until the handshake has been completed.
				 */
				if (synHandshakeState < 3) {
					if (this.handshake(synHandshakeState)) {
						//System.out.println("Sender succeeded in state " + synHandshakeState);	
						synHandshakeState++;
						//System.out.println("Sender entering state " + synHandshakeState);
					} else {
						//System.out.println("Sender failure in state " + synHandshakeState);
						/**
						 * If no SYN/ACK packet is received, send the SYN packet again
						 */
						if (synHandshakeState == 1) synHandshakeState = 0;
					}
				} else {
					/**
					 * Last ACK was already sent, so the 3-way handshake 
					 * is completed (unless the last packet was lost).
					 */
					this.setThreeWayHandshakeCompleted(true);
					//System.out.println("Sender completed 3-way handshake!");
					Frame p = this.receive(0); // timeout 0 -> infinite time
					/**
					 * The last ACK packet of the 3-way handshake could be lost.
					 * If the handshake was considered being completed and a SYN/ACK 
					 * packet is received, change the handshake state back to 2 
					 * (send last ACK)
					 */
					if (Handshake.getHandshakeSynAckPacket().equals(p)) {
						//System.out.println("Sender falling back to state 2");
						synHandshakeState = 1; continue;
					} else {
						// type 2 -> send info packet
						if (((Packet)p).getType() == 2) 
							this.handleSendRequestInfo((SendInfoPacket)(((Packet)p).getData()));
						// type 4 -> receive info packet
						if (((Packet)p).getType() == 4) 
							this.handleReceiveRequestInfo((ReceiveInfoPacket)(((Packet)p).getData()));
					}
				}
				Thread.yield();
			}
		} catch (IOException e) {
			/**
			 * Close the socket and show the error message
			 */
			//this.getSocket().close();
			System.err.println(e.toString());
		}
		System.out.println("Sender control channel was closed");
	}
		
	/**
	 * Used to send and received the 3-way handshake packets
	 * @param state 0 = send SYN, 1 = receive SYN/ACK, 2 = send ACK
	 * @throws IOException if any I/O errors occur
	 * @throws IllegalStateException If the given state is not 0, 1 or 2.
	 */
	private boolean handshake(int state) throws IOException {
		if (state == 0) {
			/**
			 * Send SYN
			 */
			//System.out.println("Sender sending SYN");
			this.send(Handshake.getHandshakeSynPacket());
			return true;
		} else if (state == 1) {
			/**
			 * Receive SYN/ACK with 30 second timeout
			 */
			//System.out.println("Sender waiting for SYN/ACK ...");
			if (Handshake.isHandshakeSynAckPacket(this.receive(5))) {
				//System.out.println("Sender received SYN/ACK!");
				return true;
			} else {
				//System.out.println("Sender failed to receive SYN/ACK");
				return false;
			}
		} else if (state == 2) {
			/**
			 * Send ACK
			 */
			//System.out.println("Sender sending ACK");
			this.send(Handshake.getHandshakeAckPacket());
			return true;
		} else {
			throw new IllegalStateException("Invalid state number");
		}
	}
	
	/**
	 * Sends a file request packet
	 * @param filename The name of the requested file
	 * @throws ArgumentException If the given filename is too long (> 255 characters).
	 * @throws IOException if any I/O error occur
	 */
	public void requestFile(String filename) throws ArgumentException, IOException {
		/**
		 * Open a new socket in a random port 
		 * for the data transfer
		 */
		VirtualDatagramSocket dataTransferSocket = 
			SocketHelper.open(this.getSocket().getLocalAddress().getHostName());
		/**
		 * Assemble the file request packet
		 */
		Packet p = new Packet();
		SendRequestPacket request = new SendRequestPacket();
		request.setFilename(filename);
		request.setPort(dataTransferSocket.getLocalPort());
		p.setType((byte)1); // type 1 -> send request packet
		p.setData(request);
		/**
		 * Already start listening for incoming packets
		 */
		Receiver receiver = new Receiver(dataTransferSocket, filename);
		/**
		 * Save reference to the request
		 * and start the thread
		 */
		this.getSendRequests().put(filename, receiver);
		this.getSendRequestSendTimes().put(filename, Calendar.getInstance().getTimeInMillis());
		new Thread(receiver).start();
		/**
		 * Send the request packet
		 */
		this.send(p);
	}

	/**
	 * Used to handle received send info packets
	 * @param sendInfoPacket A received send info packet
	 */
	private void handleSendRequestInfo(SendInfoPacket sendInfoPacket) {
		if (this.getSendRequests() == null) {
			// send info packets should not arrive 
			// if there's no sent requests..
			System.out.println("ERROR: a send-info packet was received unexpectedly");
		} else {
			Receiver r = this.getSendRequests().get(sendInfoPacket.getFilename());
			if (r == null) {
				// the file name in the received packet
				// does not match any sent request
				System.out.println("ERROR: a send-info packet was received unexpectedly");
			} else {
				this.getSendRequestSendTimes().remove(sendInfoPacket.getFilename());
				if (sendInfoPacket.getAvailable() == false) {
					System.out.println(
						String.format("ERROR: The requested file %s is not available!", 
							sendInfoPacket.getFilename()));
					r.finalize();
				} else {
					System.out.println(
						String.format("The requested file %s (%d bytes) is available!", 
							sendInfoPacket.getFilename(), sendInfoPacket.getFilesize()));
				}
			}
		}
	}
	
	/**
	 * Used to handle received receive-info packets
	 * @param receiveInfoPacket The packet to be handled
	 * @throws UnknownHostException 
	 * @throws FileNotFoundException 
	 * @throws SocketException 
	 */
	private void handleReceiveRequestInfo(ReceiveInfoPacket receiveInfoPacket) throws UnknownHostException, SocketException, FileNotFoundException {
		if (this.getReceiveRequests() == null) {
			// receive info packets should not arrive
			// if there's no sent receive requests
			System.out.println("ERROR: a receive-info packet was received unexpectedly");
		} else {
			if (this.getReceiveRequests().contains(receiveInfoPacket.getFilename())) {
				this.getReceiveRequestSendTimes().remove(receiveInfoPacket.getFilename());
				// Check was the file accepted to be received
				if (receiveInfoPacket.getAllowed()) {
					System.out.println(String.format(
						"Permission granted to send the file %s!", receiveInfoPacket.getFilename()));
					/**
					 * Open a new socket in a random port 
					 * for the data transfer
					 */
					VirtualDatagramSocket dataTransferSocket = 
						SocketHelper.open(this.getSocket().getLocalAddress().getHostName());
					/**
					 * Start sending the file
					 */
					Sender sender = new Sender(
						dataTransferSocket, // socket used in data channel
						receiveInfoPacket.getFilename(),  // sent file
						this.getSocket().getInetAddress(),  // fellow address
						receiveInfoPacket.getPort()); // fellow port
					new Thread(sender).start();
				} else {
					System.out.println(
						String.format("ERROR: The file %s was not allowed to be sent", 
							receiveInfoPacket.getFilename()));
				}
			} else {
				// the file name in the packet does
				// not match any sent request
				System.out.println("ERROR: a receive-info packet was received unexpectedly");				
			}
		}
	}
	
	/**
	 * Sends a file
	 * @param filename Path to the file to be sent
	 * @throws ArgumentException If the given file name is too long (> 255 characters)
	 * @throws IOException If any I/O errors occur
	 */
	public void sendFile(String filename) throws ArgumentException, IOException {
		if (FileHandler.exists(filename)) {
			/**
			 * Assemble the receive request packet
			 */
			Packet packet = new Packet();
			packet.setType((byte)3);
			ReceiveRequestPacket receiveRequestPacket = new ReceiveRequestPacket();
			receiveRequestPacket.setFilename(filename);
			receiveRequestPacket.setFilesize(FileHandler.getFilesize(filename));
			packet.setData(receiveRequestPacket);
			/**
			 * Save reference to the sent file
			 */
			this.getReceiveRequests().add(filename);
			this.getReceiveRequestSendTimes().put(filename, Calendar.getInstance().getTimeInMillis());
			/**
			 * Send the receive request packet
			 */
			this.send(packet);
		} else {
			System.out.println(String.format("ERROR: The file %s does not exist!", filename));
		}
	}
	
	/**
	 * Retransmits packets if needed
	 * @throws ArgumentException
	 * @throws IOException
	 */
	private void retransmit() throws ArgumentException, IOException {
		Long now = Calendar.getInstance().getTimeInMillis();
		for (Entry<String, Long> entry : this.getReceiveRequestSendTimes().entrySet()) {
			if ((now - entry.getValue()) > 10000) {
				this.requestFile(entry.getKey());
			}
		}
		for (Entry<String, Long> entry : this.getSendRequestSendTimes().entrySet()) {
			if ((now - entry.getValue()) > 10000) {
				this.sendFile(entry.getKey());
			}
		}
	}
	
}